C语言执行动态内存分配时,操作系统是如何避免“浪费”的?

在C语言程序开发中,提到动态内存分配时,基本上每个程序员都明白 calloc() 和 malloc() 库函数的区别——calloc() 函数不仅分配内存,还会将分配后的内存清零,而 malloc() 函数则对分配好的内存不做任何操作。

calloc() 函数的效率比 malloc()+memset() 函数更高?

很多C语言程序员常把 calloc() 函数看作是 malloc() + memset() 函数的组合。不过,今天我在一个偶然的测试中发现 calloc() 函数和 malloc() + memset() 组合函数的效率差异还是很大的,请看:

#include<stdio.h>
#include<stdlib.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
    int i=0;
    char *buf[10];
    while(i<10)
    {
        buf[i] = (char*)calloc(1,BLOCK_SIZE);
        i++;
    }
    return 0;
}

calloc() 函数和 malloc() + memset() 组合函数的效率差异还是很大的

这段C语言代码调用了 calloc() 函数分配了一段内存,并且重复 10 次,编译并执行之(time 命令可以查看C语言程序运行消耗的时间),得到如下结果:

# gcc t.c

# time ./a.out

**real 0m0.287s**

user 0m0.095s

sys 0m0.192s

现在将 calloc() 函数改为 malloc() + memset() 函数,修改后的C语言代码如下,请看:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
    int i=0;
    char *buf[10];
    while(i<10)
    {
        buf[i] = (char*)malloc(BLOCK_SIZE);
        memset(buf[i],'\0',BLOCK_SIZE);
        i++;
    }.
        return 0;
}

编译并执行这段C语言代码,同样使用 time 命令查看程序运行消耗时间,得到如下结果:

# gcc t.c

# time ./a.out

**real 0m2.693s**

user 0m0.973s

sys 0m1.721s

应该清楚,这两段C语言代码的工作是一致的,都是分配一段长度为 BLOCK_SIZE 的内存并且清零,但是二者消耗的时间却相差非常大,这就有一个值得深思的问题:calloc() 函数做了相同的工作,但是效率却高得多,这是怎么回事呢?

弄清楚这一点,对于我们以后开发更高效率的C语言程序肯定有所帮助。

在展开讨论之前,应该明白的是以后如果希望申请一段内容为 0 的内存,则应该优先考虑使用效率更高的 calloc() 函数,而不是 malloc() + memset() 函数的组合。

因为 calloc() 函数在内部实现中,会自行判断分配后的内存是否需要清零,如果某段分配好的内存原本就是零,那么清零动作就免去了。而 malloc() + memset() 函数的组合则全额做了“分配+清零”的动作,效率自然会有所差异。

C语言程序员应该明白四大点:程序,标准库,内核以及页表

像 malloc() 和 calloc() 这样的函数主要用于数百 KB 以下的内存分配,一般是直接从内存池(memory pool)中分配的。当内存池被用完,或者某段C语言代码一次性请求分配的内存超过剩余内存池容量时,malloc() 和 calloc() 将直接向内核请求内存。

malloc() 和 calloc() 将直接向内核请求内存

内核管理每个进程的实际内存,并确保不同进程不会干扰彼此的内存,这就是所谓的操作系统“内存保护”机制。有了这样的机制,一个进程的崩溃不会导致其他进程跟着崩溃,系统的稳定性会得到保障。

因此,在操作系统内核的管理下,当某段C语言代码需要使用一段内存时,它不能直接使用物理内存,而只能通过 mmap() 以及 sbrk() 等系统调用向内核申请,由内核修改页表为每个进程提供 RAM。

页表将内存地址映射到实际的物理 RAM,在 32 位系统上,进程地址(0x00000000到0xffffffff)不是实际的内存地址,而是虚拟内存地址,处理器将这些地址分为 4KiB 个页,通过页表,可以将每个内存页对应到不同的物理 RAM 上。

一些C语言程序员认为,calloc() 等内存分配函数是这样工作的

C语言程序调用 calloc() 申请 256KB 内存,于是标准库调用系统调用 mmap() 函数向内核申请,内核找到 256KB 未被使用的 RAM,并通过修改页表的方式将其提供给C语言程序,接着标准库调用 memset() 函数将申请到的内存清零,然后从 calloc() 函数将这段内存返回。

之后,当这段C语言程序退出后,内核会回收分配给它的内存,以便给其他进程使用。

实际上,上述过程在理论上是可行的,但是实际上并不会这样。因为内存总是有限的,内核分配给我们的C语言程序使用的内存可能是之前其他进程使用过的,如果这段内存里有密码,密钥,等其他敏感信息呢?

为了避免出现上述安全隐患,内核总是在将内存交给进程之前将其清理掉。当然了,我们也可以自己调用清零函数将使用过的内存清零,但是不管如何,mmap() 函数保证其返回的新内存是清零后的是安全的选择。

mmap() 函数保证其返回的新内存是清零后的是安全的选择

另外,有一些C语言程序可能很早就向内核申请了一段内存,但是却不会立刻使用它,甚至可能根本不会使用它。因此在设计操作系统内核时,为了效率的最大化,内核在收到内存分配请求时,不会立刻修改页表向程序提供实际的内存。

内核可能仅会将一些地址空间标记给C语言程序使用,但是却不做实际的分配工作。这样就避免了“分配了内存,却没被使用”带来的不必要的开销了。当然,一旦C语言程序需要读写这些地址空间,就会触发一个缺页异常,内核再将 RAN 真正的分配给这些地址,并恢复程序运行。

简而言之,内核为了避免不必要的开销,实际的内存分配只有在确保真的有C语言代码使用时(有写入动作时)才会进行。

也有些C语言程序分配内存后,可能(不做任何修改)直接就去读这些内存,这时,内核甚至会让这些C语言程序申请的内存指向同一个 4KiB 页表,因为 mmap() 返回的零填充内存都一样。

当然,如果某个C语言程序尝试对申请到的内存执行写入操作,那么将触发另一种缺页异常,内核将为该C语言程序分配一个新的内存页使用,该内存页不与其他任何进程共享。

避免浪费

在C语言程序开发中,一次内存分配的实际过程是这样的

C语言程序调用 calloc() 申请 256KB 内存,于是标准库调用系统调用 mmap() 函数向内核申请,内核找到 256KB 未被使用的地址空间,记下该地址空闲现在用于什么,然后返回。

现在标准库知道 mmap() 返回的结果总是用零填充,所以它不需要写入内存,因此不会出现缺页异常,内核不必直接实际分配内存。

最后C语言程序退出,内核不需要回收内存,因为内核根本就没有分配过内存。这样的效率显然很高。

如果使用 memset() 将页面清零,那么 memset() 的写入动作将触发缺页异常,内核将不得不执行分配动作,并执行写入零动作。这是一项开销巨大的工作,解释了 calloc() 比 malloc() + memset() 快的原因。

现在知道原理了

现在知道原理了,我们可以预言:如果最后使用了库函数分配的内存,那么 calloc() 函数可能仍然比 malloc()+memset() 快,但是二者之前的区别将不会再那么大。

最后应该明白,并非所有的操作系统内核都具有分页虚拟内存,因此并非在所有平台上编译C语言代码都会得到相同的结果。calloc() 函数可能并不从内核申请内存,而是从共享内存池里申请,而共享内存池中可能存储了上一次被使用时残留的垃圾数据,calloc() 可以获取到这些内存,并且调用 memset() 将其清零。

不同的操作系统管理内存很可能是不一样的,有些操作系统内核会在空闲时将内存归零,已备以后需要获得归零内存时使用,而有些则不会,例如 Linux 就不会提前将内存清零。

本页共127段,4529个字符,9174 Byte(字节)